test(0121): k6 script and load-test plan for the 100 req/s AC - #234
test(0121): k6 script and load-test plan for the 100 req/s AC#234stkrolikiewicz wants to merge 2 commits into
Conversation
Issued 2026-08-19 per the runbook's own procedure; step 5 says this table is the only record that the resource exists. Ids and limits only - the key value lives in the gitignored .env.local.
Warmup phase excluded from thresholds, asset pool from 0120's fixed list, X-Request-Id for the ClickHouse log_comment join, non-200 counted as failure, dropped_iterations as a threshold. setup() probes the pool and drops assets the API cannot serve, naming them: canonical USDC 404s (0178), which alone would put a 5% floor under an error rate the AC caps at 0.1%. README records what the pool size actually selects - the gateway caches on the path only, so pool size is the sole lever on hit rate - plus the shared-ClickHouse pre-flight borrowed from the block explorer harness.
karczuRF
left a comment
There was a problem hiding this comment.
Reviewed the k6 script, the README and the task file, and checked the infra claims against api-gateway-stack.ts / production.json.
The theme across the mediums: three of them let the harness report the SLO as met when it was not. The authoritative command still measures the cache regime; the body validation cannot fail the run because k6 checks do not affect the exit code; and setup() aborts before measuring anything in the wide-pool regime the README recommends.
Verified correct: the 10 s TTL and path-only cache key, the 200/400 stage ceiling, apiGatewayCacheEnabled, price_usd being a JSON string, encodeURIComponent on CODE:ISSUER matching the 0120 pattern, loadPool handling both pool shapes, the script-relative open() path, scenario tags merging so the {phase:main} sub-metrics work, expectedStatuses(200) surviving the object spread, and the rule-of-three arithmetic (3/30 000 = 0.01 %, 10x margin).
Nothing here blocks the approach — the script is sound and the regime analysis is the right frame. These are about the harness being unable to fail when it should.
| by the ingest/current-prices path. | ||
|
|
||
| > **The key matters, and no key that exists today will do.** Task 0157 caps the | ||
| > **The key matters, and the default plan will not do.** Task 0157 caps the |
There was a problem hiding this comment.
MEDIUM — the authoritative run command (line 18) still pins -e ASSET=native, which contradicts the regime table this PR adds.
Line 88 below now says -e ASSET=native is the cache regime, explicitly not the AC scenario. But the headline command at line 18 — the one anyone will copy for the milestone run — still carries the flag.
Failure: someone runs the documented authoritative command, measures the API Gateway cache at ~0.1 % miss, and reports the p95 as the 100 req/s AC result. That is precisely the "configuration artefact as an SLO result" failure the prose here warns about.
Dropping -e ASSET=native from line 18 makes the headline command use the new 20-asset default.
| |------|----------|--------------------|------------------------------| | ||
| | 1 asset | `-e ASSET=native` | ~30 (0.1 %) | the gateway cache | | ||
| | 20 assets (default) | *(nothing — it is the default)* | ~600 (2 %) | the AC scenario, still cache-dominated | | ||
| | 1000+ assets | `-e ASSETS=/path/pool.json` | 30 000 (100 %) | the real data path | |
There was a problem hiding this comment.
LOW — the "1000+ assets → 100 % miss" row is wrong at exactly 1000.
Asset selection is deterministic round-robin (iterationInTest % pool.length), so at RATE=100 a 1000-asset pool returns to each asset once every exactly 10.0 s — which is the cache TTL (CACHE_TTL.price). Hit vs miss becomes a timing coin-flip, so the "real data path" regime silently measures a mix and the p95 is neither number.
The real condition is pool ≫ RATE × TTL. Worth stating it that way, or naming a value with margin (≥ 2000 at 100 req/s), rather than a round number that sits exactly on the boundary.
| } | ||
| const POOL = __ENV.ASSET | ||
| ? [__ENV.ASSET] | ||
| : loadPool(__ENV.ASSETS || '../../../tools/scripts/conformance-assets.json'); |
There was a problem hiding this comment.
LOW — open() copies the pool into every VU; SharedArray would not.
Across both scenarios maxVUs totals 400, and k6 duplicates open() data per VU — that is the documented reason SharedArray from k6/data exists.
Irrelevant for the 3 KB default file, but the README recommends a 1000+ asset pool for the real-data-path regime, where this multiplies into real generator-side memory. The failure is indirect and misleading: a generator that becomes the bottleneck produces dropped iterations, the dropped_iterations threshold correctly fails, and the operator is sent to bump MAX_VUS — making it worse — rather than to the cause.
| // Excluded from thresholds — its job is to have containers already warm. | ||
| warmup: { | ||
| executor: 'constant-arrival-rate', | ||
| rate: Math.max(1, Math.round(RATE / 10)), |
There was a problem hiding this comment.
LOW/MEDIUM — the warmup does not warm the concurrency the main phase needs.
rate: RATE/10 is 10 req/s at the default, which sustains roughly one concurrent Lambda execution. The main phase at 100 req/s needs ~9-10. So around nine containers still cold-start inside the phase:main window — exactly what the header comment (lines 34-39) says the warmup exists to prevent.
Warming at the full RATE for a shorter window would actually pre-scale the concurrency.
In fairness this also undercuts the comment's own premise: ~9 cold starts in 30 000 samples will not move p95, so the effect being guarded against is smaller than described either way.
| executor: 'constant-arrival-rate', | ||
| rate: Math.max(1, Math.round(RATE / 10)), | ||
| timeUnit: '1s', | ||
| duration: WARMUP, |
There was a problem hiding this comment.
LOW — -e WARMUP=0s does not skip the warmup, it prevents the run from starting.
WARMUP is documented as a knob, and 0s is the obvious way to skip it for a quick local iteration. But it gives this scenario duration: '0s', which k6 rejects at config validation — so the test does not start at all, with an error that points at the scenario rather than at the flag.
Either guard the scenario behind a truthiness check on WARMUP, or document that it must be > 0.
| tags: { phase: 'main' }, | ||
| }, | ||
| }, | ||
| thresholds: { |
There was a problem hiding this comment.
MEDIUM — there is no checks threshold, so the new body validation cannot fail the run.
k6 check results do not affect the exit code; only thresholds do. http_req_failed{phase:main} covers status via expectedStatuses(200), but nothing here covers the body.
Failure: a regression serves 200 {"price_usd": null} (or a numeric price_usd instead of the JSON string) for all 30 000 requests. Every threshold passes, k6 exits 0, and the harness certifies the SLO as met against a broken endpoint.
The comment at line 149 — "A body-read failure must not pass as a slow 200" — asserts a guarantee the code does not currently provide. checks: ['rate>0.99'] provides it.
| 'http_req_failed{phase:main}': ['rate<0.001'], | ||
| // Dropped iterations mean k6 could not keep the offered rate — the run did | ||
| // NOT sustain 100 req/s and its p95 is not the AC's number. | ||
| dropped_iterations: ['count<1'], |
There was a problem hiding this comment.
LOW — dropped_iterations is unscoped, so a warmup drop fails the run.
The warmup scenario is deliberately excluded from every other threshold, but count<1 here is global and covers phase:warmup too. A drop during warmup — when containers are still scaling, which is the entire point of that phase — fails the run and is reported as "did NOT sustain 100 req/s".
'dropped_iterations{phase:main}': ['count<1'] matches the comment's own description of the measured window.
| // canonical USDC is exactly this case (task 0178), so the default pool needs the | ||
| // probe to be usable at all. Whatever it drops is printed: put the list in the | ||
| // report rather than letting it vanish. | ||
| export function setup() { |
There was a problem hiding this comment.
MEDIUM — setup() will time out in the wide-pool regime the README recommends.
k6's default setupTimeout is 60 s and this probe loop is strictly sequential. The README (line 88) documents 1000+ assets as the way to measure the real data path; 1000 sequential probes at the measured ~83 ms p95 is roughly 83 s, so setup() aborts the entire run before a single measured request is sent.
Set setupTimeout in options, or probe concurrently / sample the pool — before anyone tries the regime the README points them at.
| ...PARAMS, | ||
| tags: { phase: 'probe' }, | ||
| }); | ||
| (res.status === 200 ? live : dropped).push(res.status === 200 ? asset : `${asset} → ${res.status}`); |
There was a problem hiding this comment.
LOW/MEDIUM — any single non-200 is treated as permanent unservability, and the abort message misdirects.
A transient 429 or 5xx during the probe silently shrinks the pool, and the drop is reported as though the asset were dead.
The sharpest case is one this README already warns about: pricing-api-free-production is capped at 1 req/s (lines 24-30). Run with a free-tier key and this sequential probe (~12 req/s) is throttled on nearly every asset — the pool collapses and the script aborts with pool: no asset answered 200 — nothing to measure, pointing the operator at the data rather than at the key they used.
Distinguish 404 (drop) from 429/5xx (retry, or fail loudly), and name the status in the abort message.
| ...PARAMS.headers, | ||
| // Stamped into ClickHouse system.query_log.log_comment when the API runs | ||
| // with request-id logging, so a slow request can be joined to its query. | ||
| 'X-Request-Id': `lt0121-${exec.scenario.iterationInTest}-${__VU}`, |
There was a problem hiding this comment.
MEDIUM — this X-Request-Id → ClickHouse log_comment join does not exist.
grep -rniE "log_comment|x-request-id" over the repo (.rs, .ts, .md) returns nothing: the API never reads this header and never sets log_comment. The comment hedges with "when the API runs with request-id logging", but no such mode exists anywhere in the codebase, so the header is inert.
That would be harmless as a forward-looking stub, except the PR body and the task-file history both record it as a delivered capability ("X-Request-Id per request for a ClickHouse log_comment join") — which will send the next person hunting for a join that cannot be made.
Either plumb the header through the handler into log_comment, or drop the claim from the task file. Note also that with the 20-asset pool ~98 % of main-phase requests are served from the gateway cache and never reach the Lambda, so even once plumbed the join would only cover the ~2 % that miss.
Summary
manual-api-key-tier.md— the runbook's own step 5, and the only record those AWS resources exist. Plan is 150 req/s / burst 300 / 1M per month, under the stage ceiling (200/400), so 100 req/s needs no CDK change.price_load.js(rather than write a second harness): a low-rate warmup phase excluded from every threshold so Lambda cold starts do not pollute p95 over 30k samples; asset pool taken from 0120's fixed 20-asset list;X-Request-Idper request for a ClickHouselog_commentjoin; any non-200 counted as a failure;dropped_iterationsas a threshold so a run that failed to sustain the rate cannot be reported as one that did.setup()probes the pool and drops assets the API cannot serve, naming them in the output. Canonical USDC answers 404 (task 0178) and alone puts a permanent 5 % floor under an error rate the AC caps at 0.1 % — that is a data defect, not a load result, and it has to be excluded visibly rather than silently./priceon the path only, so pool size is the sole lever on hit rate, and over 300 s an asset can miss at most 30 times — making 1 / 20 / 1000+ assets the ~0 % / 2 % / 100 % miss regimes. There is noX-Cacheheader (verified), so hit and miss percentiles cannot be tagged per request; the regimes have to be run and labelled separately. Also adds the shared-ClickHouse pre-flight ritual borrowed from soroban-block-explorer's harness, whose own runbook records that our OHLCV batch can double their p95 — the same is true in reverse.Validated end to end against production at 3 req/s: probe dropped USDC, 19 assets under test, all thresholds green, p95 (measured phase) 83 ms. The 5-minute run at 100 req/s still needs a window agreed with BE and is not part of this PR.